[DE-8270] Model weights upload & download (SDK side) - #469
[DE-8270] Model weights upload & download (SDK side)#469luke-e-schaefer wants to merge 5 commits into
Conversation
Mirrors the REST surface shipped in scaleapi#149063 so users can attach a weights artifact to a model and fetch it back from Python. - `NucleusClient.upload_model_weights` / `download_model_weights` — the two methods the server PR's API-docs pages already document — plus `get_model_weights` and `delete_model_weights` for the remaining routes. - `Model.upload_weights()` / `download_weights()` / `weights()` / `delete_weights()` delegate to the client, matching how `Benchmark` does it. - New `ModelWeights` metadata type parsed from the weights DTO. The weights routes serialize camelCase both ways, unlike most of this SDK's endpoints, so the new payload keys are grouped and labelled in `constants.py`. - Transfers go straight to storage via presigned URLs and never through the API, so artifacts aren't subject to API request-size limits. Over 5 GB the server hands back multipart parts, which upload 4 at a time; `on_progress` reports `(bytes_transferred, total_bytes)`. - Size is checked against the server's 10 GB cap before presign, so an oversized file fails without a network round-trip. Two things worth knowing for review: part PUTs must be sent with *no* headers (they're signed without the Content-Type condition, so forwarding `requiredHeaders` makes S3 reject the signature), and download resolves the signed URL via `?json=1` rather than following the 302, so the API's auth headers are never sent to storage. 24 mock-based unit tests in `tests/test_model_weights.py`; version bumped to 0.19.1 (additive, per CLAUDE.md). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The docstrings are what users read, so they shouldn't describe how the artifact gets stored or moved. Dropped the presign/multipart/direct-to-storage narration from every public docstring, the `ModelWeights` attribute docs, and the CHANGELOG, leaving what a caller actually needs: what the method does, who can call it, the size limit, and the arguments. Also made the transfer helpers private (`_presign_payload`, `_transfer_weights_to_storage`, `_stream_weights_to_file`, `_finalize_payload`) so the mechanics don't show up in the generated API docs at all, rather than only being reworded. Kept the two in-body comments that explain why part uploads send no headers and why the download URL is fetched as JSON — those aren't user-visible and each one guards a real footgun. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
df6ede7 to
83c25f7
Compare
…progress Addresses two review comments: - transferred += len(chunk) ran unsynchronized across the part-upload pool, so concurrent workers could drop updates. The counter and the value handed to on_progress are now taken under a lock. - A single PUT reported nothing until it finished, then jumped to 100%. When a callback is supplied the body is wrapped so progress comes from the read side; the wrapper delegates everything but read(), so requests still sizes the body from fileno()/tell() and sends Content-Length as before.
Resolves two version-bump conflicts from #470 (v0.20.0): - pyproject.toml: 0.19.2/0.20.0 -> 0.20.1 - CHANGELOG.md: keep both sections, retitle the weights entry to 0.20.1 nucleus/__init__.py auto-merged cleanly (master touched create_benchmark, this branch adds the model-weights methods). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three fixes from review: - Download streamed straight into the destination, so an interrupted transfer left a truncated artifact that looked complete. Stream to a sibling temp file and os.replace() on success; a failed re-download now also leaves any existing artifact intact. - The multipart progress counter was locked but on_progress was called after releasing, so two threads could compute 100 and 200 and then call in either order. Invoke the callback under the same lock. - Each in-flight part is read fully into memory, so peak usage was 4 * partSizeBytes with a server-chosen part size. Bound concurrency by a 512 MB budget via _part_upload_workers(), never below 1. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
👀 |
edwinpav
left a comment
There was a problem hiding this comment.
overall looks good. mostly nit comments - not too familiar with file reading and writing but the main comments are about error handling around when a non-expected amount of bytes are read/written. also for progress bar, didn't follow it super well but i've used tqdm package in python before which works well (i think it's already used within repo as well)
| original_filename | ||
| if original_filename is not None | ||
| else os.path.basename(path), |
There was a problem hiding this comment.
nit: assign to a var before this (maybe btwn line 1738 and 1739)
| parts = _transfer_weights_to_storage( | ||
| path, presign, total_bytes, on_progress | ||
| ) |
There was a problem hiding this comment.
should this be wrapped in a try/catch?
| path, presign, total_bytes, on_progress | ||
| ) | ||
| finalized = self.make_request( | ||
| _finalize_payload(presign[UPLOAD_ID_KEY], parts), |
There was a problem hiding this comment.
presign[UPLOAD_ID_KEY] is unguarded. A presign response missing uploadId costs a complete 10 GB upload and then fails with a bare KeyError. Validate right after the presign call at 1746.
| def upload_model_weights( | ||
| self, | ||
| model: Union[Model, str], | ||
| path: str, |
There was a problem hiding this comment.
should path be ran through os.path.expanduser in both upload_model_weights and download_model_weights?
| ) | ||
| url = signed.get(URL_KEY) | ||
| if not url: | ||
| raise ValueError( |
There was a problem hiding this comment.
nit: does it make more sense for this to be NotFoundError? Up to you
| with ThreadPoolExecutor( | ||
| max_workers=_part_upload_workers(len(parts), part_size_bytes) | ||
| ) as pool: | ||
| finalized = list(pool.map(upload_part, parts)) |
There was a problem hiding this comment.
I think pool.map preserved input order so sorted() on line 239 might be redundant. worth a double check
| if on_progress is not None: | ||
| on_progress(transferred, total_bytes) | ||
| # Same directory, so this is an atomic replace. | ||
| os.replace(partial_path, path) |
There was a problem hiding this comment.
AI flag - total_bytes (from Content-Length) and transferred are never compared before os.replace
|
|
||
| return response.json() | ||
|
|
||
| def upload_weights(self, path: str, **kwargs) -> "ModelWeights": |
There was a problem hiding this comment.
instead of **kwargs, can we mirror the keyword-only params explicitly, like the Benchmark wrappers do? i think this helps type checkers and IDEs as well
There was a problem hiding this comment.
also curious, why is ModelWeights quoted here and line 369?
| assert kwargs["headers"] == {"Content-Type": "application/octet-stream"} | ||
|
|
||
|
|
||
| def test_transfer_multipart_uploads_each_part_without_headers(tmp_path): |
There was a problem hiding this comment.
can this also test / assert the bytes sent per part?
| @@ -0,0 +1,583 @@ | |||
| """Unit tests for model weights upload/download (no live API, no real S3).""" | |||
There was a problem hiding this comment.
Not super well versed with tests, but here's some suggested AI gap tests, up to you on what is worth testing. What I think are valid is 1, 2, 4, 6
- GAP — no last-partial-part coverage. Every multipart test uses a file that divides evenly; a 33-byte file at part size 16 (3 parts, 1-byte tail) is untested.
- GAP — no multipart run through upload_model_weights, so the finalize payload's parts list — order, eTag key name, presence — is never asserted on the real path.
- GAP — no test or comment for Executor.map's cancellation behavior, which the multipart failure path silently depends on.
- GAP — test_transfer_multipart_raises_without_etag uses a single part, so concurrent exception propagation is untested.
- GAP — nothing asserts timeout= is passed on either transfer. Trivial to pin, prevents a regression to an unbounded hang.
- GAP — no test for a progress callback that raises, which fires inside with progress_lock.
Summary
SDK side of model weights upload / download, mirroring the REST surface merged in scaleapi#149063.
The two primary methods are the ones the server PR's API-docs pages (
ApiDocsPage/models-python/{upload,download}-model-weights.md) already document, so the published docs and the SDK agree:Added
NucleusClient.upload_model_weights(model, path, *, content_type=None, original_filename=None, checksum_sha256=None, on_progress=None)— presign → PUT direct to storage → finalize. ReturnsModelWeights.NucleusClient.download_model_weights(model, path, *, on_progress=None)— resolves the signed URL and streams to disk (creating parent dirs). Returns the path written.get_model_weights(model)/delete_model_weights(model)for the remaining two routes.Model.upload_weights()/.download_weights()/.weights()/.delete_weights()— thin delegation to the client, matching howBenchmarkwraps its client methods.ModelWeightsmetadata type:present,status,size_bytes,original_filename,content_type,download_url.All model arguments accept either a
Modelor a bare model id (prj_*).Notes for review
requiredHeaders(which the single PUT does need) makes S3 reject the signature. There's a test pinning this in both directions — it's the easiest thing to get wrong here, and the frontend hook in 149063 has the same split.?json=1to fetch the signed URL rather than following the 302, so the API's auth headers are never sent to storage.constants.py.Tests / Version
tests/test_model_weights.py— 24 mock-based unit tests (no live API, no real S3): DTO parsing, payload builders, single vs. multipart transfer, header split, ETag/failure handling, progress callbacks, download streaming, all four client methods, and theModelwrappers.pylint nucleus10.00/10,mypy --ignore-missing-imports nucleusclean, ruff clean, black + isort clean, 61 mock-based tests passing across the eval/benchmark/leaderboard/weights suites.pyproject.toml→ 0.19.1 + CHANGELOG entry (patch bump: additive new methods, perCLAUDE.md).resolves https://linear.app/scale-epd/issue/DE-8270
🤖 Generated with Claude Code
Greptile Summary
This PR adds model weights upload/download to the Nucleus Python SDK, mirroring a REST surface already merged on the server side. Bytes flow directly to/from storage via presigned URLs, keeping large artifacts out of the API request pipeline entirely.
NucleusClient.upload_model_weightsdrives a presign → PUT → finalize flow, using_ProgressReaderfor incremental single-PUT callbacks and a locked counter for concurrent multipart callbacks. Files above 5 GB automatically switch to multipart with up to 4 concurrent part uploads, memory-bounded to 512 MB in-flight.NucleusClient.download_model_weightsfetches a signed URL via?json=1(avoiding credential forwarding on redirect) and streams to a sibling temp file, replacing the target atomically only on success to prevent truncated artifacts.Model.upload_weights / download_weights / weights / delete_weightsare thin delegation wrappers matching the pattern used byBenchmark. A newModelWeightsdataclass surfaces the artifact metadata.Confidence Score: 5/5
nucleus/__init__.pyis where the private helper imports land.Important Files Changed
_ProgressReadercorrectly delegates all file attributes to preserveContent-Lengthsizing. Atomic rename on download guards against truncated artifacts. No logic defects found.NucleusClientmethods (upload, download, get, delete weights) wired correctly to presign/transfer/finalize helpers. Private internal functions are imported into the package namespace as a side-effect of defining client methods in__init__.py.upload_weights,download_weights,weights, anddelete_weightsdelegation methods toModel. Clean thin wrappers, no logic.0.20.1entry describing the new model weights API. Version is consistent with thepyproject.tomlbump.0.20.0to0.20.1, appropriate for an additive change.Sequence Diagram
sequenceDiagram participant Caller participant NucleusClient participant NucleusAPI participant Storage as S3 Storage Note over Caller,Storage: Upload flow Caller->>NucleusClient: upload_model_weights(model, path) NucleusClient->>NucleusClient: os.path.getsize(path) ≤ 10 GB check NucleusClient->>NucleusAPI: "POST model/{id}/weights/presign" NucleusAPI-->>NucleusClient: "{uploadId, uploadUrl/parts, requiredHeaders}" alt "Single PUT (< 5 GB)" NucleusClient->>Storage: PUT uploadUrl (with requiredHeaders + ProgressReader) Storage-->>NucleusClient: ETag (ignored for single PUT) else Multipart (≥ 5 GB) par 4 concurrent workers NucleusClient->>Storage: PUT part[n].url (no extra headers) Storage-->>NucleusClient: ETag[n] end end NucleusClient->>NucleusAPI: "POST model/{id}/weights/finalize {uploadId, parts?}" NucleusAPI-->>NucleusClient: ModelWeights JSON NucleusClient-->>Caller: ModelWeights Note over Caller,Storage: Download flow Caller->>NucleusClient: download_model_weights(model, path) NucleusClient->>NucleusAPI: "GET model/{id}/weights/download?json=1" NucleusAPI-->>NucleusClient: "{url: signedGetUrl}" NucleusClient->>Storage: "GET signedGetUrl (stream=True, no API credentials)" Storage-->>NucleusClient: streamed body → tmp file NucleusClient->>NucleusClient: os.replace(tmp, path) — atomic NucleusClient-->>Caller: path writtenReviews (7): Last reviewed commit: "fix(weights): atomic download, serialize..." | Re-trigger Greptile